detached 0a69274

tui: park viewport on the last turn after resume

opening a session via /sessions (or starting with --continue /
--resume / --session) used to drop the user at the live tail
(scrollOffset = 0). on a long session that means the visible
viewport showed only the last few rows of the final assistant
reply -- the rest of the conversation was loaded into the agent
correctly but invisible without scrolling. users read this as
'resume only restored a one-liner'.

now after both code paths we compute the row offset of the last
user message and park the viewport so that user prompt sits at
the top of the chat area, with the assistant's last reply right
below. older history is one scrollup away; pgdn or arrows snap
to the live tail. the existing 'viewing turn N of M' footer
shows up automatically since parkedTurn / parkedTotal are set.

shared scrollToLastTurn helper used by both the /sessions picker
(applySessionSelection) and Run() startup. the old applySession
code is now a thin wrapper that just invalidates caches and
delegates to scrollToLastTurn.
commit
0a69274cd60b0af27394a1d3af18b45fe8ba8715
author
patriceckhart · · 2026-04-19 09:34 UTC
parent
75ed9d8
signature
unsigned
refs
tag: v0.0.10
1 file · +107 -2
M internal/agent/modes/interactive.go +107 -2
@@ -189,6 +189,17 @@ func (i *Interactive) Run(ctx context.Context) error {
189189 i.ed.SetValue(i.cfg.InitialInput)
190190 }
191191
192+ // If the agent was constructed with a pre-loaded transcript
193+ // (--continue, --resume, --session) park the viewport on the
194+ // most recent turn so the user lands looking at where the
195+ // previous session left off rather than at the bottom of an
196+ // already-rendered final reply.
197+ if i.agent != nil {
198+ if msgs := i.agent.Messages(); len(msgs) > 0 {
199+ i.scrollToLastTurn(msgs)
200+ }
201+ }
202+
192203 // No credential at startup? Auto-open the login dialog, and mark
193204 // the status line. The user can Esc out of the dialog if they
194205 // want to dismiss it (e.g. to check /help or /exit first).
@@ -1086,7 +1097,16 @@ func totalTurnsLocked(msgs []provider.Message) int {
10861097 return n
10871098 }
10881099
1089-// applySessionSelection loads the given session via the cli-provided callback.
1100+// applySessionSelection loads the given session via the cli-provided
1101+// callback and parks the viewport on the last turn so the user lands
1102+// looking at where the conversation left off (their last prompt at the
1103+// top of the chat, the assistant's last reply right below). Older
1104+// history is one scroll up; pgdn or end snaps to the current tail.
1105+//
1106+// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1107+// which on a long resumed session showed only the last few rows of
1108+// the final assistant message — the user read that as "only one liner
1109+// happened, the resume didn't work".
10901110 func (i *Interactive) applySessionSelection(path string) {
10911111 if i.cfg.LoadSession == nil {
10921112 i.mu.Lock()
@@ -1100,14 +1120,99 @@ func (i *Interactive) applySessionSelection(path string) {
11001120 i.mu.Unlock()
11011121 return
11021122 }
1123+
11031124 i.mu.Lock()
11041125 i.statusOK = "resumed session: " + path
11051126 i.statusErr = ""
1106- i.scrollOffset = 0
11071127 i.parkedTurn = 0
11081128 i.parkedTotal = 0
11091129 i.view.InvalidateRenderCache()
1130+ // Pull the freshly-loaded transcript into the view so the anchor
1131+ // math below sees the post-resume messages, not the empty pre-load
1132+ // state. redraw() does the same on its next pass; we just front-run
1133+ // it here to compute the scroll target.
1134+ if i.agent != nil {
1135+ i.view.Messages = i.agent.Messages()
1136+ }
1137+ msgs := i.view.Messages
1138+ i.mu.Unlock()
1139+
1140+ i.scrollToLastTurn(msgs)
1141+}
1142+
1143+// scrollToLastTurn parks the viewport at the most recent user turn,
1144+// or at the top if the transcript has no user messages. Used after
1145+// resume so the user lands looking at where they left off.
1146+func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1147+ if len(msgs) == 0 {
1148+ i.mu.Lock()
1149+ i.scrollOffset = 0
1150+ i.mu.Unlock()
1151+ return
1152+ }
1153+ // Find the last user message index.
1154+ lastUser := -1
1155+ turnNo, totalTurns := 0, 0
1156+ for idx, m := range msgs {
1157+ if m.Role == provider.RoleUser {
1158+ totalTurns++
1159+ lastUser = idx
1160+ }
1161+ }
1162+ if lastUser < 0 {
1163+ i.mu.Lock()
1164+ i.scrollOffset = 0
1165+ i.mu.Unlock()
1166+ return
1167+ }
1168+ turnNo = totalTurns
1169+
1170+ cols := i.lastCols()
1171+ chat, anchors := i.view.BuildWithAnchors(cols)
1172+ var row int
1173+ found := false
1174+ for _, a := range anchors {
1175+ if a.MessageIdx == lastUser {
1176+ row = a.Row
1177+ found = true
1178+ break
1179+ }
1180+ }
1181+ if !found {
1182+ i.mu.Lock()
1183+ i.scrollOffset = 0
1184+ i.mu.Unlock()
1185+ return
1186+ }
1187+
1188+ chatLen := len(chat)
1189+ page := i.chatPage()
1190+ if page < 1 {
1191+ page = 1
1192+ }
1193+ offset := chatLen - (row + page)
1194+ if offset < 0 {
1195+ offset = 0
1196+ }
1197+ maxOffset := chatLen - page
1198+ if maxOffset < 0 {
1199+ maxOffset = 0
1200+ }
1201+ if offset > maxOffset {
1202+ offset = maxOffset
1203+ }
1204+
1205+ i.mu.Lock()
1206+ i.scrollOffset = offset
1207+ // Mark the parked-turn footer so the user sees "viewing turn N of
1208+ // M · pgdn to catch up" — same affordance as /jump. Tells them at
1209+ // a glance that they're looking at history, not the live tail.
1210+ if offset > 0 {
1211+ i.parkedTurn = turnNo
1212+ i.parkedTotal = totalTurns
1213+ }
11101214 i.mu.Unlock()
1215+ i.invalidate()
11111216 }
11121217
11131218 func (i *Interactive) applyModelSelection(prov, model string) {
@@ -189,6 +189,17 @@ func (i *Interactive) Run(ctx context.Context) error {
189 i.ed.SetValue(i.cfg.InitialInput)189 i.ed.SetValue(i.cfg.InitialInput)
190 }190 }
191 191
192+ // If the agent was constructed with a pre-loaded transcript
193+ // (--continue, --resume, --session) park the viewport on the
194+ // most recent turn so the user lands looking at where the
195+ // previous session left off rather than at the bottom of an
196+ // already-rendered final reply.
197+ if i.agent != nil {
198+ if msgs := i.agent.Messages(); len(msgs) > 0 {
199+ i.scrollToLastTurn(msgs)
200+ }
201+ }
202+
192 // No credential at startup? Auto-open the login dialog, and mark203 // No credential at startup? Auto-open the login dialog, and mark
193 // the status line. The user can Esc out of the dialog if they204 // the status line. The user can Esc out of the dialog if they
194 // want to dismiss it (e.g. to check /help or /exit first).205 // want to dismiss it (e.g. to check /help or /exit first).
@@ -1086,7 +1097,16 @@ func totalTurnsLocked(msgs []provider.Message) int {
1086 return n1097 return n
1087 }1098 }
1088 1099
1089-// applySessionSelection loads the given session via the cli-provided callback.1100+// applySessionSelection loads the given session via the cli-provided
1101+// callback and parks the viewport on the last turn so the user lands
1102+// looking at where the conversation left off (their last prompt at the
1103+// top of the chat, the assistant's last reply right below). Older
1104+// history is one scroll up; pgdn or end snaps to the current tail.
1105+//
1106+// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1107+// which on a long resumed session showed only the last few rows of
1108+// the final assistant message — the user read that as "only one liner
1109+// happened, the resume didn't work".
1090 func (i *Interactive) applySessionSelection(path string) {1110 func (i *Interactive) applySessionSelection(path string) {
1091 if i.cfg.LoadSession == nil {1111 if i.cfg.LoadSession == nil {
1092 i.mu.Lock()1112 i.mu.Lock()
@@ -1100,14 +1120,99 @@ func (i *Interactive) applySessionSelection(path string) {
1100 i.mu.Unlock()1120 i.mu.Unlock()
1101 return1121 return
1102 }1122 }
1123+
1103 i.mu.Lock()1124 i.mu.Lock()
1104 i.statusOK = "resumed session: " + path1125 i.statusOK = "resumed session: " + path
1105 i.statusErr = ""1126 i.statusErr = ""
1106- i.scrollOffset = 0
1107 i.parkedTurn = 01127 i.parkedTurn = 0
1108 i.parkedTotal = 01128 i.parkedTotal = 0
1109 i.view.InvalidateRenderCache()1129 i.view.InvalidateRenderCache()
1130+ // Pull the freshly-loaded transcript into the view so the anchor
1131+ // math below sees the post-resume messages, not the empty pre-load
1132+ // state. redraw() does the same on its next pass; we just front-run
1133+ // it here to compute the scroll target.
1134+ if i.agent != nil {
1135+ i.view.Messages = i.agent.Messages()
1136+ }
1137+ msgs := i.view.Messages
1138+ i.mu.Unlock()
1139+
1140+ i.scrollToLastTurn(msgs)
1141+}
1142+
1143+// scrollToLastTurn parks the viewport at the most recent user turn,
1144+// or at the top if the transcript has no user messages. Used after
1145+// resume so the user lands looking at where they left off.
1146+func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1147+ if len(msgs) == 0 {
1148+ i.mu.Lock()
1149+ i.scrollOffset = 0
1150+ i.mu.Unlock()
1151+ return
1152+ }
1153+ // Find the last user message index.
1154+ lastUser := -1
1155+ turnNo, totalTurns := 0, 0
1156+ for idx, m := range msgs {
1157+ if m.Role == provider.RoleUser {
1158+ totalTurns++
1159+ lastUser = idx
1160+ }
1161+ }
1162+ if lastUser < 0 {
1163+ i.mu.Lock()
1164+ i.scrollOffset = 0
1165+ i.mu.Unlock()
1166+ return
1167+ }
1168+ turnNo = totalTurns
1169+
1170+ cols := i.lastCols()
1171+ chat, anchors := i.view.BuildWithAnchors(cols)
1172+ var row int
1173+ found := false
1174+ for _, a := range anchors {
1175+ if a.MessageIdx == lastUser {
1176+ row = a.Row
1177+ found = true
1178+ break
1179+ }
1180+ }
1181+ if !found {
1182+ i.mu.Lock()
1183+ i.scrollOffset = 0
1184+ i.mu.Unlock()
1185+ return
1186+ }
1187+
1188+ chatLen := len(chat)
1189+ page := i.chatPage()
1190+ if page < 1 {
1191+ page = 1
1192+ }
1193+ offset := chatLen - (row + page)
1194+ if offset < 0 {
1195+ offset = 0
1196+ }
1197+ maxOffset := chatLen - page
1198+ if maxOffset < 0 {
1199+ maxOffset = 0
1200+ }
1201+ if offset > maxOffset {
1202+ offset = maxOffset
1203+ }
1204+
1205+ i.mu.Lock()
1206+ i.scrollOffset = offset
1207+ // Mark the parked-turn footer so the user sees "viewing turn N of
1208+ // M · pgdn to catch up" — same affordance as /jump. Tells them at
1209+ // a glance that they're looking at history, not the live tail.
1210+ if offset > 0 {
1211+ i.parkedTurn = turnNo
1212+ i.parkedTotal = totalTurns
1213+ }
1110 i.mu.Unlock()1214 i.mu.Unlock()
1215+ i.invalidate()
1111 }1216 }
1112 1217
1113 func (i *Interactive) applyModelSelection(prov, model string) {1218 func (i *Interactive) applyModelSelection(prov, model string) {

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