feat(modes): add session info command
- commit
- 978d6fff1f786c65e1866b3f52f097277f47e09a
- author
- brice · · 2026-08-18 14:02 UTC
- parent
- cabc432
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 307 | 307 | | Command | Description | | |
| 308 | 308 | |---|---| | |
| 309 | 309 | | `/help` | Show key bindings and commands. | | |
| 310 | + | | `/info` | Show the current session ID, full transcript-file path, working directory, provider, and model. | | |
| 310 | 311 | | `/login` | Log in via API key or subscription (opens a dialog). | | |
| 311 | 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 | 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 | 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 | 912 | ## 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 { | |||
| 446 | 446 | statusOK string | |
| 447 | 447 | statusErr string | |
| 448 | 448 | help string | |
| 449 | + | sessionInfo string | |
| 449 | 450 | extNotes string | |
| 450 | 451 | extStatuses string | |
| 451 | 452 | extWidgets string | |
| @@ -682,6 +683,7 @@ type Interactive struct { | |||
| 682 | 683 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |
| 683 | 684 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 684 | 685 | helpBlock []string // rendered above the chat when /help was typed | |
| 686 | + | sessionInfoBlock []string // rendered above the chat when /info was typed | |
| 685 | 687 | cumUsage provider.Usage | |
| 686 | 688 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 687 | 689 | busy bool | |
| @@ -446,6 +446,7 @@ type chatCacheKey struct { | |||||
| 446 | statusOK string | 446 | statusOK string | ||
| 447 | statusErr string | 447 | statusErr string | ||
| 448 | help string | 448 | help string | ||
| 449 | + | sessionInfo string | |||
| 449 | extNotes string | 450 | extNotes string | ||
| 450 | extStatuses string | 451 | extStatuses string | ||
| 451 | extWidgets string | 452 | extWidgets string | ||
| @@ -682,6 +683,7 @@ type Interactive struct { | |||||
| 682 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | 683 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | ||
| 683 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 684 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| 684 | helpBlock []string // rendered above the chat when /help was typed | 685 | helpBlock []string // rendered above the chat when /help was typed | ||
| 686 | + | sessionInfoBlock []string // rendered above the chat when /info was typed | |||
| 685 | cumUsage provider.Usage | 687 | cumUsage provider.Usage | ||
| 686 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 688 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 687 | busy bool | 689 | 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) { | |||
| 228 | 228 | i.statusErr = "" | |
| 229 | 229 | i.statusOK = "" | |
| 230 | 230 | i.helpBlock = nil | |
| 231 | + | i.sessionInfoBlock = nil | |
| 231 | 232 | i.parkedTurn = 0 | |
| 232 | 233 | i.parkedTotal = 0 | |
| 233 | 234 | i.scrollOffset = 0 | |
| @@ -242,6 +243,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 242 | 243 | case "/help": | |
| 243 | 244 | i.mu.Lock() | |
| 244 | 245 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 246 | + | i.sessionInfoBlock = nil | |
| 245 | 247 | i.statusErr = "" | |
| 246 | 248 | i.statusOK = "" | |
| 247 | 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 | 251 | // what the user actually sees. | |
| 250 | 252 | i.scrollOffset = 0 | |
| 251 | 253 | i.mu.Unlock() | |
| 254 | + | case "/info": | |
| 255 | + | i.showSessionInfo() | |
| 252 | 256 | case "/login": | |
| 253 | 257 | i.dialog.Open(i.cfg.ZutHome) | |
| 254 | 258 | case "/logout": | |
| @@ -397,6 +401,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 397 | 401 | i.toolOrder = nil | |
| 398 | 402 | i.toolGate = map[string]int{} | |
| 399 | 403 | i.helpBlock = nil | |
| 404 | + | i.sessionInfoBlock = nil | |
| 400 | 405 | i.parkedTurn = 0 | |
| 401 | 406 | i.statusOK = "cwd " + i.cfg.CWD | |
| 402 | 407 | 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 = nil | 230 | i.helpBlock = nil | ||
| 231 | + | i.sessionInfoBlock = nil | |||
| 231 | i.parkedTurn = 0 | 232 | i.parkedTurn = 0 | ||
| 232 | i.parkedTotal = 0 | 233 | i.parkedTotal = 0 | ||
| 233 | i.scrollOffset = 0 | 234 | 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 = 0 | 252 | 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 = nil | 401 | i.toolOrder = nil | ||
| 398 | i.toolGate = map[string]int{} | 402 | i.toolGate = map[string]int{} | ||
| 399 | i.helpBlock = nil | 403 | i.helpBlock = nil | ||
| 404 | + | i.sessionInfoBlock = nil | |||
| 400 | i.parkedTurn = 0 | 405 | i.parkedTurn = 0 | ||
| 401 | i.statusOK = "cwd " + i.cfg.CWD | 406 | 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 | |||
| 119 | 119 | i.toolOrder = nil | |
| 120 | 120 | i.toolGate = map[string]int{} | |
| 121 | 121 | i.helpBlock = nil | |
| 122 | + | i.sessionInfoBlock = nil | |
| 122 | 123 | i.extNotes = nil | |
| 123 | 124 | i.parkedTurn = 0 | |
| 124 | 125 | i.parkedTotal = 0 | |
| @@ -174,6 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 174 | 175 | i.toolOrder = nil | |
| 175 | 176 | i.toolGate = map[string]int{} | |
| 176 | 177 | i.helpBlock = nil | |
| 178 | + | i.sessionInfoBlock = nil | |
| 177 | 179 | i.parkedTurn = 0 | |
| 178 | 180 | i.statusErr = "" | |
| 179 | 181 | i.mu.Unlock() | |
| @@ -119,6 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||||
| 119 | i.toolOrder = nil | 119 | i.toolOrder = nil | ||
| 120 | i.toolGate = map[string]int{} | 120 | i.toolGate = map[string]int{} | ||
| 121 | i.helpBlock = nil | 121 | i.helpBlock = nil | ||
| 122 | + | i.sessionInfoBlock = nil | |||
| 122 | i.extNotes = nil | 123 | i.extNotes = nil | ||
| 123 | i.parkedTurn = 0 | 124 | i.parkedTurn = 0 | ||
| 124 | i.parkedTotal = 0 | 125 | i.parkedTotal = 0 | ||
| @@ -174,6 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 174 | i.toolOrder = nil | 175 | i.toolOrder = nil | ||
| 175 | i.toolGate = map[string]int{} | 176 | i.toolGate = map[string]int{} | ||
| 176 | i.helpBlock = nil | 177 | i.helpBlock = nil | ||
| 178 | + | i.sessionInfoBlock = nil | |||
| 177 | i.parkedTurn = 0 | 179 | 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 { | |||
| 42 | 42 | statusOK: i.statusOK, | |
| 43 | 43 | statusErr: i.statusErr, | |
| 44 | 44 | help: strings.Join(i.helpBlock, "\n"), | |
| 45 | + | sessionInfo: strings.Join(i.sessionInfoBlock, "\n"), | |
| 45 | 46 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 46 | 47 | extStatuses: i.extensionStatusesKeyLocked(), | |
| 47 | 48 | extWidgets: i.extensionWidgetsKeyLocked(), | |
| @@ -349,6 +350,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 349 | 350 | if len(i.helpBlock) > 0 { | |
| 350 | 351 | chat = append(chat, i.helpBlock...) | |
| 351 | 352 | } | |
| 353 | + | if len(i.sessionInfoBlock) > 0 { | |
| 354 | + | chat = append(chat, i.sessionInfoBlock...) | |
| 355 | + | } | |
| 352 | 356 | ||
| 353 | 357 | if i.statusOK != "" { | |
| 354 | 358 | // 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 long | 358 | // 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 { | |||
| 47 | 47 | // Keep in sync with runSlash(). | |
| 48 | 48 | var slashCatalog = []slashCommand{ | |
| 49 | 49 | {Name: "/help", Desc: "show key bindings and commands"}, | |
| 50 | + | {Name: "/info", Desc: "show the current session's details and file path"}, | |
| 50 | 51 | {Name: "/login", Desc: "log in via api key or subscription"}, | |
| 51 | 52 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 52 | 53 | {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>)"}, | ||