detached 978d6ff

feat(modes): add session info command

commit
978d6fff1f786c65e1866b3f52f097277f47e09a
author
brice · · 2026-08-18 14:02 UTC
parent
cabc432
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +174 -1
M README.md +2 -1
@@ -307,6 +307,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
307307 | Command | Description |
308308 |---|---|
309309 | `/help` | Show key bindings and commands. |
310+| `/info` | Show the current session ID, full transcript-file path, working directory, provider, and model. |
310311 | `/login` | Log in via API key or subscription (opens a dialog). |
311312 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |
312313 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
@@ -905,7 +906,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
905906
906907 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
907908
908-Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
909+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
909910
910911
911912 ## Keys (interactive mode)
@@ -307,6 +307,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
307 | Command | Description |307 | Command | Description |
308 |---|---|308 |---|---|
309 | `/help` | Show key bindings and commands. |309 | `/help` | Show key bindings and commands. |
310+| `/info` | Show the current session ID, full transcript-file path, working directory, provider, and model. |
310 | `/login` | Log in via API key or subscription (opens a dialog). |311 | `/login` | Log in via API key or subscription (opens a dialog). |
311 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |312 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |
312 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |313 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
@@ -905,7 +906,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
905 906
906 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.907 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
907 908
908-Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.909+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
909 910
910 911
911 ## Keys (interactive mode)912 ## Keys (interactive mode)
M packages/agent/modes/interactive.go +2 -0
@@ -446,6 +446,7 @@ type chatCacheKey struct {
446446 statusOK string
447447 statusErr string
448448 help string
449+ sessionInfo string
449450 extNotes string
450451 extStatuses string
451452 extWidgets string
@@ -682,6 +683,7 @@ type Interactive struct {
682683 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
683684 liveBlock []string // live streaming/tool progress rendered outside scrollback
684685 helpBlock []string // rendered above the chat when /help was typed
686+ sessionInfoBlock []string // rendered above the chat when /info was typed
685687 cumUsage provider.Usage
686688 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
687689 busy bool
@@ -446,6 +446,7 @@ type chatCacheKey struct {
446 statusOK string446 statusOK string
447 statusErr string447 statusErr string
448 help string448 help string
449+ sessionInfo string
449 extNotes string450 extNotes string
450 extStatuses string451 extStatuses string
451 extWidgets string452 extWidgets string
@@ -682,6 +683,7 @@ type Interactive struct {
682 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible683 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
683 liveBlock []string // live streaming/tool progress rendered outside scrollback684 liveBlock []string // live streaming/tool progress rendered outside scrollback
684 helpBlock []string // rendered above the chat when /help was typed685 helpBlock []string // rendered above the chat when /help was typed
686+ sessionInfoBlock []string // rendered above the chat when /info was typed
685 cumUsage provider.Usage687 cumUsage provider.Usage
686 lastCtxInput int // input_tokens of the most recent turn — approximates current context size688 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
687 busy bool689 busy bool
M packages/agent/modes/interactive_commands.go +5 -0
@@ -228,6 +228,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
228228 i.statusErr = ""
229229 i.statusOK = ""
230230 i.helpBlock = nil
231+ i.sessionInfoBlock = nil
231232 i.parkedTurn = 0
232233 i.parkedTotal = 0
233234 i.scrollOffset = 0
@@ -242,6 +243,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
242243 case "/help":
243244 i.mu.Lock()
244245 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
246+ i.sessionInfoBlock = nil
245247 i.statusErr = ""
246248 i.statusOK = ""
247249 // Pin the viewport to the newest content so the help block,
@@ -249,6 +251,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
249251 // what the user actually sees.
250252 i.scrollOffset = 0
251253 i.mu.Unlock()
254+ case "/info":
255+ i.showSessionInfo()
252256 case "/login":
253257 i.dialog.Open(i.cfg.ZutHome)
254258 case "/logout":
@@ -397,6 +401,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
397401 i.toolOrder = nil
398402 i.toolGate = map[string]int{}
399403 i.helpBlock = nil
404+ i.sessionInfoBlock = nil
400405 i.parkedTurn = 0
401406 i.statusOK = "cwd " + i.cfg.CWD
402407 i.statusErr = ""
@@ -228,6 +228,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
228 i.statusErr = ""228 i.statusErr = ""
229 i.statusOK = ""229 i.statusOK = ""
230 i.helpBlock = nil230 i.helpBlock = nil
231+ i.sessionInfoBlock = nil
231 i.parkedTurn = 0232 i.parkedTurn = 0
232 i.parkedTotal = 0233 i.parkedTotal = 0
233 i.scrollOffset = 0234 i.scrollOffset = 0
@@ -242,6 +243,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
242 case "/help":243 case "/help":
243 i.mu.Lock()244 i.mu.Lock()
244 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)245 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
246+ i.sessionInfoBlock = nil
245 i.statusErr = ""247 i.statusErr = ""
246 i.statusOK = ""248 i.statusOK = ""
247 // Pin the viewport to the newest content so the help block,249 // Pin the viewport to the newest content so the help block,
@@ -249,6 +251,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
249 // what the user actually sees.251 // what the user actually sees.
250 i.scrollOffset = 0252 i.scrollOffset = 0
251 i.mu.Unlock()253 i.mu.Unlock()
254+ case "/info":
255+ i.showSessionInfo()
252 case "/login":256 case "/login":
253 i.dialog.Open(i.cfg.ZutHome)257 i.dialog.Open(i.cfg.ZutHome)
254 case "/logout":258 case "/logout":
@@ -397,6 +401,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
397 i.toolOrder = nil401 i.toolOrder = nil
398 i.toolGate = map[string]int{}402 i.toolGate = map[string]int{}
399 i.helpBlock = nil403 i.helpBlock = nil
404+ i.sessionInfoBlock = nil
400 i.parkedTurn = 0405 i.parkedTurn = 0
401 i.statusOK = "cwd " + i.cfg.CWD406 i.statusOK = "cwd " + i.cfg.CWD
402 i.statusErr = ""407 i.statusErr = ""
M packages/agent/modes/interactive_host.go +2 -0
@@ -119,6 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
119119 i.toolOrder = nil
120120 i.toolGate = map[string]int{}
121121 i.helpBlock = nil
122+ i.sessionInfoBlock = nil
122123 i.extNotes = nil
123124 i.parkedTurn = 0
124125 i.parkedTotal = 0
@@ -174,6 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
174175 i.toolOrder = nil
175176 i.toolGate = map[string]int{}
176177 i.helpBlock = nil
178+ i.sessionInfoBlock = nil
177179 i.parkedTurn = 0
178180 i.statusErr = ""
179181 i.mu.Unlock()
@@ -119,6 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
119 i.toolOrder = nil119 i.toolOrder = nil
120 i.toolGate = map[string]int{}120 i.toolGate = map[string]int{}
121 i.helpBlock = nil121 i.helpBlock = nil
122+ i.sessionInfoBlock = nil
122 i.extNotes = nil123 i.extNotes = nil
123 i.parkedTurn = 0124 i.parkedTurn = 0
124 i.parkedTotal = 0125 i.parkedTotal = 0
@@ -174,6 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
174 i.toolOrder = nil175 i.toolOrder = nil
175 i.toolGate = map[string]int{}176 i.toolGate = map[string]int{}
176 i.helpBlock = nil177 i.helpBlock = nil
178+ i.sessionInfoBlock = nil
177 i.parkedTurn = 0179 i.parkedTurn = 0
178 i.statusErr = ""180 i.statusErr = ""
179 i.mu.Unlock()181 i.mu.Unlock()
M packages/agent/modes/interactive_rendering.go +4 -0
@@ -42,6 +42,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
4242 statusOK: i.statusOK,
4343 statusErr: i.statusErr,
4444 help: strings.Join(i.helpBlock, "\n"),
45+ sessionInfo: strings.Join(i.sessionInfoBlock, "\n"),
4546 extNotes: strings.Join(i.extNotes, "\n"),
4647 extStatuses: i.extensionStatusesKeyLocked(),
4748 extWidgets: i.extensionWidgetsKeyLocked(),
@@ -349,6 +350,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
349350 if len(i.helpBlock) > 0 {
350351 chat = append(chat, i.helpBlock...)
351352 }
353+ if len(i.sessionInfoBlock) > 0 {
354+ chat = append(chat, i.sessionInfoBlock...)
355+ }
352356
353357 if i.statusOK != "" {
354358 // Hard-truncate the OK line to the visible width so a long
@@ -42,6 +42,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
42 statusOK: i.statusOK,42 statusOK: i.statusOK,
43 statusErr: i.statusErr,43 statusErr: i.statusErr,
44 help: strings.Join(i.helpBlock, "\n"),44 help: strings.Join(i.helpBlock, "\n"),
45+ sessionInfo: strings.Join(i.sessionInfoBlock, "\n"),
45 extNotes: strings.Join(i.extNotes, "\n"),46 extNotes: strings.Join(i.extNotes, "\n"),
46 extStatuses: i.extensionStatusesKeyLocked(),47 extStatuses: i.extensionStatusesKeyLocked(),
47 extWidgets: i.extensionWidgetsKeyLocked(),48 extWidgets: i.extensionWidgetsKeyLocked(),
@@ -349,6 +350,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
349 if len(i.helpBlock) > 0 {350 if len(i.helpBlock) > 0 {
350 chat = append(chat, i.helpBlock...)351 chat = append(chat, i.helpBlock...)
351 }352 }
353+ if len(i.sessionInfoBlock) > 0 {
354+ chat = append(chat, i.sessionInfoBlock...)
355+ }
352 356
353 if i.statusOK != "" {357 if i.statusOK != "" {
354 // Hard-truncate the OK line to the visible width so a long358 // Hard-truncate the OK line to the visible width so a long
A packages/agent/modes/session_info.go +102 -0
@@ -0,0 +1,102 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/tui"
10+ "github.com/mattn/go-runewidth"
11+)
12+
13+// renderSessionInfoBlock formats information owned by the live interactive
14+// session. Paths remain absolute so users can copy the transcript location.
15+func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd, provider, model string) []string {
16+ if width < 20 {
17+ width = 20
18+ }
19+
20+ rows := [][2]string{
21+ {"session id", valueOrUnavailable(sessionID)},
22+ {"session file", valueOrUnavailable(sessionPath)},
23+ {"working directory", valueOrUnavailable(cwd)},
24+ {"provider", valueOrUnavailable(provider)},
25+ {"model", valueOrUnavailable(model)},
26+ }
27+ labelWidth := 14
28+ for _, row := range rows {
29+ if n := runewidth.StringWidth(row[0]); n > labelWidth {
30+ labelWidth = n
31+ }
32+ }
33+
34+ pad := func(label string) string {
35+ return label + strings.Repeat(" ", labelWidth-runewidth.StringWidth(label))
36+ }
37+
38+ out := []string{frameHeader(th, "session info", width), ""}
39+ for _, row := range rows {
40+ out = append(out, fmt.Sprintf(" %s %s",
41+ th.FGColor(th.Accent, pad(row[0])),
42+ th.FGColor(th.Muted, row[1])))
43+ }
44+ return append(out, "", frameRule(th, width), "")
45+}
46+
47+func valueOrUnavailable(value string) string {
48+ if strings.TrimSpace(value) == "" {
49+ return "unavailable"
50+ }
51+ return value
52+}
53+
54+func absoluteSessionInfoPath(path string) string {
55+ if path == "" {
56+ return ""
57+ }
58+ absolute, err := filepath.Abs(path)
59+ if err != nil {
60+ return path
61+ }
62+ return absolute
63+}
64+
65+func (i *Interactive) showSessionInfo() {
66+ i.mu.Lock()
67+ agent := i.agent
68+ pathFn := i.cfg.CurrentSessionPath
69+ cwd := i.cfg.CWD
70+ providerName := i.cfg.Provider
71+ model := i.cfg.Model
72+ theme := i.cfg.Theme
73+ terminal := i.cfg.Terminal
74+ i.mu.Unlock()
75+
76+ sessionID := sessionID(agent)
77+ sessionPath := ""
78+ if pathFn != nil {
79+ sessionPath = pathFn()
80+ }
81+
82+ width := 80
83+ if terminal != nil {
84+ width, _ = terminal.Size()
85+ }
86+ block := renderSessionInfoBlock(theme, width, sessionID, absoluteSessionInfoPath(sessionPath), absoluteSessionInfoPath(cwd), providerName, model)
87+
88+ i.mu.Lock()
89+ i.helpBlock = nil
90+ i.sessionInfoBlock = block
91+ i.statusErr = ""
92+ i.statusOK = ""
93+ i.scrollOffset = 0
94+ i.mu.Unlock()
95+}
96+
97+func sessionID(agent *core.Agent) string {
98+ if agent == nil {
99+ return ""
100+ }
101+ return agent.SessionID()
102+}
@@ -0,0 +1,102 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/tui"
10+ "github.com/mattn/go-runewidth"
11+)
12+
13+// renderSessionInfoBlock formats information owned by the live interactive
14+// session. Paths remain absolute so users can copy the transcript location.
15+func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd, provider, model string) []string {
16+ if width < 20 {
17+ width = 20
18+ }
19+
20+ rows := [][2]string{
21+ {"session id", valueOrUnavailable(sessionID)},
22+ {"session file", valueOrUnavailable(sessionPath)},
23+ {"working directory", valueOrUnavailable(cwd)},
24+ {"provider", valueOrUnavailable(provider)},
25+ {"model", valueOrUnavailable(model)},
26+ }
27+ labelWidth := 14
28+ for _, row := range rows {
29+ if n := runewidth.StringWidth(row[0]); n > labelWidth {
30+ labelWidth = n
31+ }
32+ }
33+
34+ pad := func(label string) string {
35+ return label + strings.Repeat(" ", labelWidth-runewidth.StringWidth(label))
36+ }
37+
38+ out := []string{frameHeader(th, "session info", width), ""}
39+ for _, row := range rows {
40+ out = append(out, fmt.Sprintf(" %s %s",
41+ th.FGColor(th.Accent, pad(row[0])),
42+ th.FGColor(th.Muted, row[1])))
43+ }
44+ return append(out, "", frameRule(th, width), "")
45+}
46+
47+func valueOrUnavailable(value string) string {
48+ if strings.TrimSpace(value) == "" {
49+ return "unavailable"
50+ }
51+ return value
52+}
53+
54+func absoluteSessionInfoPath(path string) string {
55+ if path == "" {
56+ return ""
57+ }
58+ absolute, err := filepath.Abs(path)
59+ if err != nil {
60+ return path
61+ }
62+ return absolute
63+}
64+
65+func (i *Interactive) showSessionInfo() {
66+ i.mu.Lock()
67+ agent := i.agent
68+ pathFn := i.cfg.CurrentSessionPath
69+ cwd := i.cfg.CWD
70+ providerName := i.cfg.Provider
71+ model := i.cfg.Model
72+ theme := i.cfg.Theme
73+ terminal := i.cfg.Terminal
74+ i.mu.Unlock()
75+
76+ sessionID := sessionID(agent)
77+ sessionPath := ""
78+ if pathFn != nil {
79+ sessionPath = pathFn()
80+ }
81+
82+ width := 80
83+ if terminal != nil {
84+ width, _ = terminal.Size()
85+ }
86+ block := renderSessionInfoBlock(theme, width, sessionID, absoluteSessionInfoPath(sessionPath), absoluteSessionInfoPath(cwd), providerName, model)
87+
88+ i.mu.Lock()
89+ i.helpBlock = nil
90+ i.sessionInfoBlock = block
91+ i.statusErr = ""
92+ i.statusOK = ""
93+ i.scrollOffset = 0
94+ i.mu.Unlock()
95+}
96+
97+func sessionID(agent *core.Agent) string {
98+ if agent == nil {
99+ return ""
100+ }
101+ return agent.SessionID()
102+}
A packages/agent/modes/session_info_test.go +56 -0
@@ -0,0 +1,56 @@
1+package modes
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func TestRunInfoShowsCurrentSessionDetails(t *testing.T) {
14+ root := t.TempDir()
15+ sessionPath := filepath.Join(root, "state", "session.jsonl")
16+ cwd := filepath.Join(root, "workspace")
17+ agent := &core.Agent{}
18+ if err := agent.BindSessionID("session-123"); err != nil {
19+ t.Fatal(err)
20+ }
21+ i := &Interactive{
22+ agent: agent,
23+ cfg: InteractiveConfig{
24+ CWD: cwd,
25+ Provider: "openai-codex",
26+ Model: "gpt-5.6",
27+ CurrentSessionPath: func() string { return sessionPath },
28+ },
29+ }
30+
31+ i.runSlash(context.Background(), "/INFO")
32+
33+ got := strings.Join(i.sessionInfoBlock, "\n")
34+ for _, want := range []string{
35+ "session info",
36+ "session-123",
37+ sessionPath,
38+ cwd,
39+ "openai-codex",
40+ "gpt-5.6",
41+ } {
42+ if !strings.Contains(got, want) {
43+ t.Fatalf("/info output missing %q:\n%s", want, got)
44+ }
45+ }
46+ if len(i.helpBlock) != 0 {
47+ t.Fatalf("/info retained help block: %#v", i.helpBlock)
48+ }
49+}
50+
51+func TestRenderSessionInfoMarksUnavailableValues(t *testing.T) {
52+ got := strings.Join(renderSessionInfoBlock(tui.Theme{}, 80, "", "", "", "", ""), "\n")
53+ if count := strings.Count(got, "unavailable"); count != 5 {
54+ t.Fatalf("unavailable value count = %d, want 5:\n%s", count, got)
55+ }
56+}
@@ -0,0 +1,56 @@
1+package modes
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func TestRunInfoShowsCurrentSessionDetails(t *testing.T) {
14+ root := t.TempDir()
15+ sessionPath := filepath.Join(root, "state", "session.jsonl")
16+ cwd := filepath.Join(root, "workspace")
17+ agent := &core.Agent{}
18+ if err := agent.BindSessionID("session-123"); err != nil {
19+ t.Fatal(err)
20+ }
21+ i := &Interactive{
22+ agent: agent,
23+ cfg: InteractiveConfig{
24+ CWD: cwd,
25+ Provider: "openai-codex",
26+ Model: "gpt-5.6",
27+ CurrentSessionPath: func() string { return sessionPath },
28+ },
29+ }
30+
31+ i.runSlash(context.Background(), "/INFO")
32+
33+ got := strings.Join(i.sessionInfoBlock, "\n")
34+ for _, want := range []string{
35+ "session info",
36+ "session-123",
37+ sessionPath,
38+ cwd,
39+ "openai-codex",
40+ "gpt-5.6",
41+ } {
42+ if !strings.Contains(got, want) {
43+ t.Fatalf("/info output missing %q:\n%s", want, got)
44+ }
45+ }
46+ if len(i.helpBlock) != 0 {
47+ t.Fatalf("/info retained help block: %#v", i.helpBlock)
48+ }
49+}
50+
51+func TestRenderSessionInfoMarksUnavailableValues(t *testing.T) {
52+ got := strings.Join(renderSessionInfoBlock(tui.Theme{}, 80, "", "", "", "", ""), "\n")
53+ if count := strings.Count(got, "unavailable"); count != 5 {
54+ t.Fatalf("unavailable value count = %d, want 5:\n%s", count, got)
55+ }
56+}
M packages/agent/modes/slash_suggest.go +1 -0
@@ -47,6 +47,7 @@ func slashCommandCancelsTurn(cmd string) bool {
4747 // Keep in sync with runSlash().
4848 var slashCatalog = []slashCommand{
4949 {Name: "/help", Desc: "show key bindings and commands"},
50+ {Name: "/info", Desc: "show the current session's details and file path"},
5051 {Name: "/login", Desc: "log in via api key or subscription"},
5152 {Name: "/logout", Desc: "clear a provider's credentials"},
5253 {Name: "/model", Desc: "pick a model (or /model <id>)"},
@@ -47,6 +47,7 @@ func slashCommandCancelsTurn(cmd string) bool {
47 // Keep in sync with runSlash().47 // Keep in sync with runSlash().
48 var slashCatalog = []slashCommand{48 var slashCatalog = []slashCommand{
49 {Name: "/help", Desc: "show key bindings and commands"},49 {Name: "/help", Desc: "show key bindings and commands"},
50+ {Name: "/info", Desc: "show the current session's details and file path"},
50 {Name: "/login", Desc: "log in via api key or subscription"},51 {Name: "/login", Desc: "log in via api key or subscription"},
51 {Name: "/logout", Desc: "clear a provider's credentials"},52 {Name: "/logout", Desc: "clear a provider's credentials"},
52 {Name: "/model", Desc: "pick a model (or /model <id>)"},53 {Name: "/model", Desc: "pick a model (or /model <id>)"},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close