Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 2a33ef7

Browse filesBrowse files
feat: full-file diff view, two-pane diff tab, and git sidebar enhancements (#232)
Co-authored-by: Yashas Salankimatt <yashas.salankimatt@gmail.com>
1 parent 2b08295 commit 2a33ef7
Copy full SHA for 2a33ef7

15 files changed

+1,921-78Lines changed: 1921 additions & 78 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎internal/keymap/bindings.go‎

Copy file name to clipboardExpand all lines: internal/keymap/bindings.go
+4Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ func DefaultBindings() []Binding {
387387
{Key: "F", Command: "fetch-pr", Context: "workspace-list"},
388388
{Key: "+", Command: "resize-pane-grow", Context: "workspace-list"},
389389
{Key: "-", Command: "resize-pane-shrink", Context: "workspace-list"},
390+
{Key: "ctrl+t", Command: "toggle-terminal", Context: "workspace-list"},
391+
{Key: "alt+t", Command: "switch-terminal-layout", Context: "workspace-list"},
390392

391393
// Workspace fetch PR context
392394
{Key: "esc", Command: "cancel", Context: "workspace-fetch-pr"},
@@ -414,6 +416,8 @@ func DefaultBindings() []Binding {
414416
{Key: "ctrl+u", Command: "page-up", Context: "workspace-preview"},
415417
{Key: "+", Command: "resize-pane-grow", Context: "workspace-preview"},
416418
{Key: "-", Command: "resize-pane-shrink", Context: "workspace-preview"},
419+
{Key: "ctrl+t", Command: "toggle-terminal", Context: "workspace-preview"},
420+
{Key: "alt+t", Command: "switch-terminal-layout", Context: "workspace-preview"},
417421

418422
// Workspace merge error context
419423
{Key: "esc", Command: "dismiss-merge-error", Context: "workspace-merge-error"},
Collapse file

‎internal/plugins/workspace/agent.go‎

Copy file name to clipboardExpand all lines: internal/plugins/workspace/agent.go
+5-1Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,7 +924,11 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd {
924924
if !interactiveCapture && features.IsEnabled(features.TmuxInteractiveInput.Name) {
925925
if selected := p.selectedWorktree(); selected != nil && selected.Name == worktreeName {
926926
directCapture = true
927-
previewWidth, previewHeight = p.calculatePreviewDimensions()
927+
if p.termPanelVisible {
928+
previewWidth, previewHeight = p.calculateAgentPaneDimensions()
929+
} else {
930+
previewWidth, previewHeight = p.calculatePreviewDimensions()
931+
}
928932
resizeTarget = p.previewResizeTarget()
929933
}
930934
}
Collapse file

‎internal/plugins/workspace/commands.go‎

Copy file name to clipboardExpand all lines: internal/plugins/workspace/commands.go
+36Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,25 @@ func (p *Plugin) Commands() []plugin.Command {
164164
)
165165
}
166166
}
167+
// Terminal panel toggle (show on Output tab when an agent or shell is active)
168+
if p.previewTab == PreviewTabOutput || p.shellSelected {
169+
termName := "Term"
170+
if p.termPanelVisible {
171+
termName = "Hide"
172+
}
173+
cmds = append(cmds,
174+
plugin.Command{ID: "toggle-terminal", Name: termName, Description: "Toggle terminal panel", Context: "workspace-preview", Priority: 16},
175+
)
176+
if p.termPanelVisible {
177+
layoutName := "Right"
178+
if p.termPanelLayout == TermPanelRight {
179+
layoutName = "Bottom"
180+
}
181+
cmds = append(cmds,
182+
plugin.Command{ID: "switch-terminal-layout", Name: layoutName, Description: "Switch terminal layout", Context: "workspace-preview", Priority: 17},
183+
)
184+
}
185+
}
167186
return cmds
168187
}
169188

@@ -236,6 +255,23 @@ func (p *Plugin) Commands() []plugin.Command {
236255
)
237256
}
238257
}
258+
// Terminal panel toggle (available from sidebar too)
259+
termName := "Term"
260+
if p.termPanelVisible {
261+
termName = "Hide"
262+
}
263+
cmds = append(cmds,
264+
plugin.Command{ID: "toggle-terminal", Name: termName, Description: "Toggle terminal panel", Context: "workspace-list", Priority: 17},
265+
)
266+
if p.termPanelVisible {
267+
layoutName := "Right"
268+
if p.termPanelLayout == TermPanelRight {
269+
layoutName = "Bottom"
270+
}
271+
cmds = append(cmds,
272+
plugin.Command{ID: "switch-terminal-layout", Name: layoutName, Description: "Switch terminal layout", Context: "workspace-list", Priority: 18},
273+
)
274+
}
239275
return cmds
240276
}
241277
}
Collapse file

‎internal/plugins/workspace/diff.go‎

Copy file name to clipboardExpand all lines: internal/plugins/workspace/diff.go
+44Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ type FullFileDiffLoadedMsg struct {
140140
NewContent string
141141
Parsed *gitstatus.ParsedDiff
142142
FilePath string
143+
CommitHash string // Non-empty when loaded for a commit file diff
143144
}
144145

145146
// GetEpoch implements plugin.EpochMessage.
@@ -194,6 +195,49 @@ func (p *Plugin) loadFullFileDiffForWorkspace() tea.Cmd {
194195
}
195196
}
196197

198+
// loadFullFileDiffForCommit loads full-file content for the currently selected commit file.
199+
func (p *Plugin) loadFullFileDiffForCommit() tea.Cmd {
200+
wt := p.selectedWorktree()
201+
if wt == nil || p.commitDetail == nil {
202+
return nil
203+
}
204+
if p.commitFileCursor < 0 || p.commitFileCursor >= len(p.commitDetail.Files) {
205+
return nil
206+
}
207+
208+
file := p.commitDetail.Files[p.commitFileCursor]
209+
filePath := file.Path
210+
commitHash := p.commitDetail.Hash
211+
parentHash := ""
212+
if p.commitDetail.IsMerge && len(p.commitDetail.ParentHashes) > 0 {
213+
parentHash = p.commitDetail.ParentHashes[0]
214+
}
215+
workdir := wt.Path
216+
epoch := p.ctx.Epoch
217+
name := wt.Name
218+
219+
return func() tea.Msg {
220+
parentRef := commitHash + "~1"
221+
if parentHash != "" {
222+
parentRef = parentHash
223+
}
224+
oldContent, _ := gitstatus.GetFileContentAtRef(workdir, filePath, parentRef)
225+
newContent, _ := gitstatus.GetFileContentAtRef(workdir, filePath, commitHash)
226+
rawDiff, _ := gitstatus.GetCommitDiff(workdir, commitHash, filePath, parentHash)
227+
parsed, _ := gitstatus.ParseUnifiedDiff(rawDiff)
228+
229+
return FullFileDiffLoadedMsg{
230+
Epoch: epoch,
231+
WorkspaceName: name,
232+
OldContent: oldContent,
233+
NewContent: newContent,
234+
Parsed: parsed,
235+
FilePath: filePath,
236+
CommitHash: commitHash,
237+
}
238+
}
239+
}
240+
197241
// splitLines splits a string into lines, handling various line endings.
198242
func splitLines(s string) []string {
199243
var lines []string
Collapse file

‎internal/plugins/workspace/interactive.go‎

Copy file name to clipboardExpand all lines: internal/plugins/workspace/interactive.go
+163-4Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,13 @@ func (p *Plugin) enterInteractiveMode() tea.Cmd {
454454
target = sessionName // Fall back to session name if pane ID not available
455455
}
456456
if target != "" {
457-
previewWidth, previewHeight := p.calculatePreviewDimensions()
457+
// When terminal panel is visible, agent pane only gets a portion
458+
var previewWidth, previewHeight int
459+
if p.termPanelVisible {
460+
previewWidth, previewHeight = p.calculateAgentPaneDimensions()
461+
} else {
462+
previewWidth, previewHeight = p.calculatePreviewDimensions()
463+
}
458464
tty.SetWindowSizeManual(sessionName)
459465
p.resizeTmuxPane(target, previewWidth, previewHeight)
460466
// Verify and retry once if resize didn't take effect
@@ -499,6 +505,49 @@ func (p *Plugin) enterInteractiveMode() tea.Cmd {
499505
return tea.Batch(cmds...)
500506
}
501507

508+
// enterTermPanelInteractiveMode enters interactive mode targeting the terminal panel's tmux session.
509+
func (p *Plugin) enterTermPanelInteractiveMode() tea.Cmd {
510+
if !features.IsEnabled(features.TmuxInteractiveInput.Name) {
511+
return nil
512+
}
513+
if p.termPanelSession == "" || !p.termPanelVisible {
514+
return nil
515+
}
516+
517+
sessionName := p.termPanelSession
518+
paneID := p.termPanelPaneID
519+
target := paneID
520+
if target == "" {
521+
target = sessionName
522+
}
523+
524+
// Resize terminal panel pane to match its split dimensions
525+
w, h := p.calculateTermPanelDimensions()
526+
tty.SetWindowSizeManual(sessionName)
527+
p.resizeTmuxPane(target, w, h)
528+
if aw, ah, ok := queryPaneSize(target); ok && (aw != w || ah != h) {
529+
p.resizeTmuxPane(target, w, h)
530+
}
531+
532+
p.termPanelScroll = 0 // Reset scroll so output aligns with cursor position
533+
p.interactiveState = &InteractiveState{
534+
Active: true,
535+
TargetPane: paneID,
536+
TargetSession: sessionName,
537+
TermPanel: true,
538+
LastKeyTime: time.Now(),
539+
CursorVisible: true,
540+
}
541+
p.selection.Clear()
542+
p.viewMode = ViewModeInteractive
543+
544+
// Invalidate the background poll chain so only the interactive poll loop runs.
545+
// The interactive chain captures the same pane and updates termPanelOutput,
546+
// so background polling is redundant during interactive mode.
547+
p.termPanelGeneration++
548+
return p.pollInteractivePane()
549+
}
550+
502551
// calculatePreviewDimensions returns the content width and height for the preview pane.
503552
// Used to resize tmux panes to match the visible area.
504553
// IMPORTANT: This must stay in sync with renderListView() width calculations.
@@ -580,7 +629,17 @@ func (p *Plugin) resizeTmuxTargetCmd(target string) tea.Cmd {
580629
return nil
581630
}
582631

583-
previewWidth, previewHeight := p.calculatePreviewDimensions()
632+
// Determine dimensions: terminal panel target gets terminal panel dims,
633+
// agent target gets split-aware dims, or full dims if no panel.
634+
var previewWidth, previewHeight int
635+
isTermPanel := p.termPanelVisible && (target == p.termPanelPaneID || target == p.termPanelSession)
636+
if isTermPanel {
637+
previewWidth, previewHeight = p.calculateTermPanelDimensions()
638+
} else if p.termPanelVisible {
639+
previewWidth, previewHeight = p.calculateAgentPaneDimensions()
640+
} else {
641+
previewWidth, previewHeight = p.calculatePreviewDimensions()
642+
}
584643
return func() tea.Msg {
585644
if actualWidth, actualHeight, ok := queryPaneSize(target); ok {
586645
if actualWidth == previewWidth && actualHeight == previewHeight {
@@ -605,7 +664,15 @@ func (p *Plugin) maybeResizeInteractivePane(paneWidth, paneHeight int) tea.Cmd {
605664
return nil
606665
}
607666

608-
previewWidth, previewHeight := p.calculatePreviewDimensions()
667+
var previewWidth, previewHeight int
668+
isTermPanel := p.interactiveState.TermPanel
669+
if isTermPanel && p.termPanelVisible {
670+
previewWidth, previewHeight = p.calculateTermPanelDimensions()
671+
} else if p.termPanelVisible {
672+
previewWidth, previewHeight = p.calculateAgentPaneDimensions()
673+
} else {
674+
previewWidth, previewHeight = p.calculatePreviewDimensions()
675+
}
609676
if paneWidth == previewWidth && paneHeight == previewHeight {
610677
return nil
611678
}
@@ -743,6 +810,8 @@ func (p *Plugin) previewResizeTarget() string {
743810
// exitInteractiveMode exits interactive mode and returns to list view.
744811
func (p *Plugin) exitInteractiveMode() {
745812
if p.interactiveState != nil {
813+
// Preserve focus on whichever sub-pane was interactive
814+
p.termPanelFocused = p.interactiveState.TermPanel
746815
p.interactiveState.Active = false
747816
}
748817
p.interactiveState = nil
@@ -773,10 +842,37 @@ func (p *Plugin) handleInteractiveKeys(msg tea.KeyMsg) tea.Cmd {
773842
return p.pollSelectedAgentNowIfVisible()
774843
}
775844

845+
// Terminal panel toggle: intercept before forwarding to tmux
846+
if msg.String() == "ctrl+t" {
847+
cmd := p.toggleTermPanel()
848+
// If interactive mode survived the toggle (agent pane still active),
849+
// keep focus on agent pane and resize the interactive pane.
850+
if p.interactiveState != nil && p.interactiveState.Active && !p.interactiveState.TermPanel {
851+
p.termPanelFocused = false
852+
return tea.Batch(cmd, p.resizeInteractivePaneCmd())
853+
}
854+
return cmd
855+
}
856+
if msg.String() == "alt+t" {
857+
cmd := p.switchTermPanelLayout()
858+
if p.interactiveState != nil && p.interactiveState.Active {
859+
return tea.Batch(cmd, p.resizeInteractivePaneCmd())
860+
}
861+
return cmd
862+
}
863+
776864
// Attach shortcut: exit interactive and attach to full session (td-fd68d1)
777865
if msg.String() == p.getInteractiveAttachKey() {
866+
isTermPanel := p.interactiveState != nil && p.interactiveState.TermPanel
778867
p.exitInteractiveMode()
779-
// Attach to the appropriate session
868+
// Terminal panel: attach to its tmux session
869+
if isTermPanel && p.termPanelSession != "" {
870+
sessionName := p.termPanelSession
871+
return p.attachWithResize(sessionName, sessionName, "terminal", func(err error) tea.Msg {
872+
return TmuxAttachFinishedMsg{Err: err}
873+
})
874+
}
875+
// Attach to the appropriate agent/shell session
780876
if p.shellSelected {
781877
if idx := p.selectedShellIdx; idx >= 0 && idx < len(p.shells) {
782878
return p.ensureShellAndAttachByIndex(idx)
@@ -1052,6 +1148,18 @@ func (p *Plugin) forwardScrollToTmux(delta int) tea.Cmd {
10521148
}
10531149
p.lastScrollTime = now
10541150

1151+
// When interactive mode targets the terminal panel, scroll terminal panel output
1152+
if p.interactiveState != nil && p.interactiveState.TermPanel {
1153+
if delta < 0 {
1154+
p.termPanelScroll++
1155+
} else {
1156+
if p.termPanelScroll > 0 {
1157+
p.termPanelScroll--
1158+
}
1159+
}
1160+
return nil
1161+
}
1162+
10551163
if delta < 0 {
10561164
// Scroll up: pause auto-scroll, show older content
10571165
p.autoScrollOutput = false
@@ -1149,13 +1257,46 @@ func (p *Plugin) interactiveMouseCoords(x, y int) (col, row int, ok bool) {
11491257
}
11501258
contentY++ // hint line
11511259

1260+
// When interactive mode targets the terminal panel, adjust content origin
1261+
// to account for the terminal panel's position within the preview area.
1262+
targetingTermPanel := p.interactiveState != nil && p.interactiveState.Active && p.interactiveState.TermPanel && p.termPanelVisible
1263+
if targetingTermPanel {
1264+
previewWidth, previewHeight := p.calculatePreviewDimensions()
1265+
size := p.termPanelEffectiveSize()
1266+
if p.termPanelLayout == TermPanelRight {
1267+
termWidth := previewWidth * size / 100
1268+
if termWidth < 10 {
1269+
termWidth = 10
1270+
}
1271+
outputWidth := previewWidth - termWidth - 1
1272+
if outputWidth < 10 {
1273+
outputWidth = 10
1274+
}
1275+
contentX += outputWidth + 1 // skip agent output + divider
1276+
} else {
1277+
termHeight := previewHeight * size / 100
1278+
if termHeight < 3 {
1279+
termHeight = 3
1280+
}
1281+
outputHeight := previewHeight - termHeight - 1
1282+
if outputHeight < 3 {
1283+
outputHeight = 3
1284+
}
1285+
contentY += outputHeight + 1 // skip agent output + divider
1286+
}
1287+
contentY++ // terminal panel hint/label line
1288+
}
1289+
11521290
relX := x - contentX
11531291
relY := y - contentY
11541292
if relX < 0 || relY < 0 {
11551293
return 0, 0, false
11561294
}
11571295

11581296
paneWidth, paneHeight := p.calculatePreviewDimensions()
1297+
if targetingTermPanel {
1298+
paneWidth, paneHeight = p.calculateTermPanelDimensions()
1299+
}
11591300
if p.interactiveState != nil {
11601301
if p.interactiveState.PaneWidth > 0 && p.interactiveState.PaneWidth < paneWidth {
11611302
paneWidth = p.interactiveState.PaneWidth
@@ -1207,6 +1348,11 @@ func (p *Plugin) pollInteractivePane() tea.Cmd {
12071348
interval = pollingDecayMedium
12081349
}
12091350

1351+
// When interactive mode targets the terminal panel, use terminal panel polling
1352+
if p.interactiveState.TermPanel {
1353+
return p.scheduleTermPanelPoll(interval)
1354+
}
1355+
12101356
// Use existing shell or worktree polling mechanism
12111357
// Worktrees use scheduleInteractivePoll to skip stagger (td-8856c9)
12121358
if p.shellSelected && p.selectedShellIdx >= 0 && p.selectedShellIdx < len(p.shells) {
@@ -1225,6 +1371,14 @@ func (p *Plugin) scheduleDebouncedPoll(delay time.Duration) tea.Cmd {
12251371
return nil
12261372
}
12271373

1374+
// When interactive mode targets the terminal panel, use terminal panel polling.
1375+
// Increment generation to invalidate stale timers from previous keystrokes,
1376+
// preventing poll chain accumulation during rapid typing.
1377+
if p.interactiveState.TermPanel {
1378+
p.termPanelGeneration++
1379+
return p.scheduleTermPanelPoll(delay)
1380+
}
1381+
12281382
// Use shell or worktree polling mechanism based on current selection.
12291383
// IMPORTANT: Use the correct generation map for each type (td-97327e):
12301384
// - Shells use shellPollGeneration (checked by scheduleShellPollByName)
@@ -1256,6 +1410,11 @@ func (p *Plugin) pollInteractivePaneImmediate() tea.Cmd {
12561410
return nil
12571411
}
12581412

1413+
// When interactive mode targets the terminal panel, use terminal panel polling
1414+
if p.interactiveState.TermPanel {
1415+
return p.scheduleTermPanelPoll(0)
1416+
}
1417+
12591418
// Schedule with 0ms delay for immediate capture (td-8856c9: no stagger for worktrees)
12601419
if p.shellSelected && p.selectedShellIdx >= 0 && p.selectedShellIdx < len(p.shells) {
12611420
return p.scheduleShellPollByName(p.shells[p.selectedShellIdx].TmuxName, 0)

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.