Skip to content

Commit 67ed4b9

Browse files
phiatclaude
andcommitted
Port PR #2 from Rust: text toggle, solo mode, dedup fix, tool names on output
- Fix show_text default (false → true) — text output was hidden with no toggle - Add Text toggle (x key) in header - Add solo mode (s key) — isolate a session/agent in tree view - Remove destructive x/d delete — space toggle + solo covers filtering - Show tool name on output headers (e.g., "Bash result (1.2s)") - Fix dedup to key on (ToolID, Type) — input+output with same ID both kept - Rename "Auto" to "Scroll" in header - Update README keybindings Based on phiat/claude-esp-rs#2 by @rdmontgomery Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e277ee5 commit 67ed4b9

File tree

5 files changed

+118
-22
lines changed

5 files changed

+118
-22
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,15 @@ claude-esp -l
105105
| `t` | Toggle thinking visibility |
106106
| `i` | Toggle tool input visibility |
107107
| `o` | Toggle tool output visibility |
108+
| `x` | Toggle text/response visibility |
108109
| `a` | Toggle auto-scroll |
109110
| `h` | Hide/show tree pane |
110111
| `A` | Toggle auto-discovery of new sessions |
111112
| `tab` | Switch focus between tree and stream |
112113
| `j/k/↑/↓` | Navigate tree or scroll stream |
113114
| `space` | Toggle selected item in tree |
115+
| `s` | Solo selected session/agent (toggle) |
114116
| `enter` | Load background task output (when selected)|
115-
| `x/d` | Remove selected session from tree |
116117
| `g/G` | Go to top/bottom of stream |
117118
| `q` | Quit |
118119

internal/tui/model.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,13 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
253253
m.stream.ToggleAutoScroll()
254254
}
255255

256-
case "x", "d":
257-
// Remove selected session (only when tree is focused)
258-
if m.focus == FocusTree && m.watcher != nil {
259-
sessionID := m.tree.GetSelectedSession()
260-
if sessionID != "" {
261-
m.watcher.RemoveSession(sessionID)
262-
m.tree.RemoveSession(sessionID)
263-
m.stream.SetEnabledFilters(m.tree.GetEnabledFilters())
264-
}
256+
case "x":
257+
m.stream.ToggleText()
258+
259+
case "s":
260+
if m.focus == FocusTree {
261+
m.tree.Solo()
262+
m.stream.SetEnabledFilters(m.tree.GetEnabledFilters())
265263
}
266264

267265
case "A":
@@ -377,11 +375,12 @@ func (m *Model) renderHeader() string {
377375
thinking := m.renderToggle("Thinking", m.stream.IsThinkingEnabled(), "t")
378376
toolInput := m.renderToggle("Tools", m.stream.IsToolInputEnabled(), "i")
379377
toolOutput := m.renderToggle("Output", m.stream.IsToolOutputEnabled(), "o")
380-
autoScroll := m.renderToggle("Auto", m.stream.IsAutoScrollEnabled(), "a")
378+
textToggle := m.renderToggle("Text", m.stream.IsTextEnabled(), "x")
379+
autoScroll := m.renderToggle("Scroll", m.stream.IsAutoScrollEnabled(), "a")
381380
treeToggle := m.renderToggle("Tree", m.showTree, "h")
382381

383-
toggles := fmt.Sprintf("%s %s %s %s %s",
384-
thinking, toolInput, toolOutput, autoScroll, treeToggle)
382+
toggles := fmt.Sprintf("%s %s %s %s %s %s",
383+
thinking, toolInput, toolOutput, textToggle, autoScroll, treeToggle)
385384

386385
// Session count and auto-discovery status
387386
sessionInfo := ""
@@ -475,7 +474,7 @@ func (m *Model) renderStreamOnly() string {
475474
func (m *Model) renderHelp() string {
476475
var help string
477476
if m.focus == FocusTree {
478-
help = "j/k: navigate │ space: toggle │ x: remove │ A: auto-discover │ q: quit"
477+
help = "j/k: navigate │ space: toggle │ s: solo │ A: auto-discover │ q: quit"
479478
} else {
480479
help = "j/k: scroll │ g/G: top/bottom │ A: auto-discover │ tab: tree │ q: quit"
481480
}

internal/tui/stream.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func NewStreamView() *StreamView {
4747
showThinking: true,
4848
showToolInput: true,
4949
showToolOutput: true,
50-
showText: false, // hide main text by default
50+
showText: true,
5151
enabledFilters: []EnabledFilter{},
5252
}
5353
}
@@ -63,12 +63,14 @@ func (s *StreamView) SetSize(width, height int) {
6363

6464
// AddItem adds a new item to the stream
6565
func (s *StreamView) AddItem(item parser.StreamItem) {
66-
// Deduplicate tool input/output by ToolID
66+
// Deduplicate by (ToolID, Type) so tool input and output
67+
// with the same tool_id are both kept
6768
if item.ToolID != "" {
68-
if s.seenToolIDs[item.ToolID] {
69+
dedupKey := fmt.Sprintf("%s:%s", item.ToolID, item.Type)
70+
if s.seenToolIDs[dedupKey] {
6971
return // Skip duplicate
7072
}
71-
s.seenToolIDs[item.ToolID] = true
73+
s.seenToolIDs[dedupKey] = true
7274
}
7375

7476
s.items = append(s.items, item)
@@ -103,6 +105,12 @@ func (s *StreamView) ToggleToolOutput() {
103105
s.updateContent()
104106
}
105107

108+
// ToggleText toggles text visibility
109+
func (s *StreamView) ToggleText() {
110+
s.showText = !s.showText
111+
s.updateContent()
112+
}
113+
106114
// ToggleAutoScroll toggles auto-scroll
107115
func (s *StreamView) ToggleAutoScroll() {
108116
s.autoScroll = !s.autoScroll
@@ -134,6 +142,11 @@ func (s *StreamView) IsToolOutputEnabled() bool {
134142
return s.showToolOutput
135143
}
136144

145+
// IsTextEnabled returns text filter state
146+
func (s *StreamView) IsTextEnabled() bool {
147+
return s.showText
148+
}
149+
137150
// IsAutoScrollEnabled returns auto-scroll state
138151
func (s *StreamView) IsAutoScrollEnabled() bool {
139152
return s.autoScroll
@@ -215,7 +228,22 @@ func (s *StreamView) renderItem(item parser.StreamItem, width int) string {
215228
b.WriteString(toolInputContentStyle.Render(content))
216229

217230
case parser.TypeToolOutput:
218-
outputLabel := toolOutputIcon + " Output"
231+
// Look up tool name from matching ToolInput
232+
toolName := ""
233+
if item.ToolID != "" {
234+
for _, other := range s.items {
235+
if other.Type == parser.TypeToolInput && other.ToolID == item.ToolID {
236+
toolName = other.ToolName
237+
break
238+
}
239+
}
240+
}
241+
var outputLabel string
242+
if toolName != "" {
243+
outputLabel = toolOutputIcon + " " + toolName + " result"
244+
} else {
245+
outputLabel = toolOutputIcon + " Output"
246+
}
219247
if item.DurationMs > 0 {
220248
outputLabel += " " + formatDuration(item.DurationMs)
221249
}

internal/tui/stream_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func TestStreamView_Deduplication(t *testing.T) {
4141
s := NewStreamView()
4242
s.SetSize(80, 24)
4343

44+
// Input and output with same ToolID should both be kept (different types)
4445
item1 := parser.StreamItem{
4546
Type: parser.TypeToolInput,
4647
SessionID: "sess1",
@@ -57,10 +58,16 @@ func TestStreamView_Deduplication(t *testing.T) {
5758
}
5859

5960
s.AddItem(item1)
60-
s.AddItem(item2) // same ToolID, should be skipped
61+
s.AddItem(item2)
6162

62-
if len(s.items) != 1 {
63-
t.Errorf("expected 1 item (duplicate skipped), got %d", len(s.items))
63+
if len(s.items) != 2 {
64+
t.Errorf("expected 2 items (input + output kept), got %d", len(s.items))
65+
}
66+
67+
// Duplicate of same type + same ToolID should be skipped
68+
s.AddItem(item1)
69+
if len(s.items) != 2 {
70+
t.Errorf("expected 2 items (true duplicate skipped), got %d", len(s.items))
6471
}
6572
}
6673

internal/tui/tree.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,67 @@ func (t *TreeView) Toggle() {
288288
}
289289
}
290290

291+
// Solo isolates the selected node: disables all others, enables only this one.
292+
// If already soloed, re-enables all.
293+
func (t *TreeView) Solo() {
294+
if t.cursor < 0 || t.cursor >= len(t.nodes) {
295+
return
296+
}
297+
selected := t.nodes[t.cursor]
298+
299+
if t.isSoloed(selected) {
300+
// Un-solo: re-enable everything
301+
setAllEnabled(t.Root, true)
302+
} else {
303+
// Disable all sessions and their children
304+
for _, session := range t.Root.Children {
305+
setAllEnabled(session, false)
306+
}
307+
308+
// Enable the selected node and the path to it
309+
switch selected.Type {
310+
case NodeTypeSession:
311+
setAllEnabled(selected, true)
312+
case NodeTypeMain, NodeTypeAgent:
313+
if selected.Parent != nil {
314+
selected.Parent.Enabled = true
315+
}
316+
selected.Enabled = true
317+
}
318+
}
319+
}
320+
321+
func (t *TreeView) isSoloed(selected *TreeNode) bool {
322+
if !selected.Enabled {
323+
return false
324+
}
325+
326+
for _, session := range t.Root.Children {
327+
if selected.Type == NodeTypeSession {
328+
if session != selected && session.Enabled {
329+
return false
330+
}
331+
} else {
332+
for _, child := range session.Children {
333+
if child.Type == NodeTypeBackgroundTask {
334+
continue
335+
}
336+
if child != selected && child.Enabled {
337+
return false
338+
}
339+
}
340+
}
341+
}
342+
return true
343+
}
344+
345+
func setAllEnabled(node *TreeNode, enabled bool) {
346+
node.Enabled = enabled
347+
for _, child := range node.Children {
348+
setAllEnabled(child, enabled)
349+
}
350+
}
351+
291352
// GetSelectedSession returns the session ID of the currently selected node (or its parent session)
292353
func (t *TreeView) GetSelectedSession() string {
293354
if t.cursor < 0 || t.cursor >= len(t.nodes) {

0 commit comments

Comments
 (0)