@@ -56,6 +56,7 @@ import { useEditorContext } from "@tui/context/editor"
5656import { useDialog } from "../../ui/dialog"
5757import { TodoItem } from "../../component/todo-item"
5858import { DialogMessage } from "./dialog-message"
59+ import { DialogTimestamp } from "./dialog-timestamp"
5960import type { PromptInfo } from "../../component/prompt/history"
6061import { DialogConfirm } from "@tui/ui/dialog-confirm"
6162import { DialogTimeline } from "./dialog-timeline"
@@ -83,6 +84,13 @@ import { UI } from "@/cli/ui.ts"
8384import { useTuiConfig } from "../../context/tui-config"
8485import { nextThinkingMode , reasoningTitle , useThinkingMode , type ThinkingMode } from "../../context/thinking"
8586import { getScrollAcceleration } from "../../util/scroll"
87+ import {
88+ getTimestampsMode ,
89+ hourMinute ,
90+ nextTimestampsMode ,
91+ normalizeTimestampsMode ,
92+ type TimestampsMode ,
93+ } from "../../util/timestamps"
8694import { collapseToolOutput } from "../../util/collapse-tool-output"
8795import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
8896import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -160,6 +168,7 @@ const context = createContext<{
160168 thinkingMode : ( ) => ThinkingMode
161169 showThinking : ( ) => boolean
162170 showTimestamps : ( ) => boolean
171+ timestampsMode : ( ) => TimestampsMode
163172 showDetails : ( ) => boolean
164173 showGenericToolOutput : ( ) => boolean
165174 diffWrapMode : ( ) => "word" | "none"
@@ -218,7 +227,12 @@ export function Session() {
218227 const thinking = useThinkingMode ( )
219228 const thinkingMode = thinking . mode
220229 const showThinking = createMemo ( ( ) => true )
221- const [ timestamps , setTimestamps ] = kv . signal < "hide" | "show" > ( "timestamps" , "hide" )
230+ // The kv signal was historically "hide" | "show"; expanded to include "footer"
231+ // and "gutter". Default seeded from tui.json (timestamps_mode, default "hide").
232+ // Legacy "show" values are normalized to "footer" so users keep their toggle.
233+ const timestampsDefault : TimestampsMode = getTimestampsMode ( tuiConfig )
234+ const [ timestampsRaw , setTimestamps ] = kv . signal < TimestampsMode > ( "timestamps" , timestampsDefault )
235+ const timestamps = createMemo ( ( ) => normalizeTimestampsMode ( timestampsRaw ( ) , timestampsDefault ) )
222236 const [ showDetails , setShowDetails ] = kv . signal ( "tool_details_visibility" , true )
223237 const [ showAssistantMetadata , _setShowAssistantMetadata ] = kv . signal ( "assistant_metadata_visibility" , true )
224238 const [ showScrollbar , setShowScrollbar ] = kv . signal ( "scrollbar_visible" , false )
@@ -233,7 +247,11 @@ export function Session() {
233247 if ( sidebar ( ) === "auto" && wide ( ) ) return true
234248 return false
235249 } )
236- const showTimestamps = createMemo ( ( ) => timestamps ( ) === "show" )
250+ // Backwards-compatible alias: existing call sites that gate the footer-style
251+ // timestamp render check `showTimestamps()`. It now means "render the footer",
252+ // which is the "footer" mode only — gutter mode renders the time elsewhere.
253+ const timestampsMode = createMemo < TimestampsMode > ( ( ) => timestamps ( ) )
254+ const showTimestamps = createMemo ( ( ) => timestampsMode ( ) === "footer" )
237255 const contentWidth = createMemo ( ( ) => dimensions ( ) . width - ( sidebarVisible ( ) ? 42 : 0 ) - 4 )
238256 const providers = createMemo ( ( ) => Model . index ( sync . data . provider ) )
239257
@@ -673,15 +691,21 @@ export function Session() {
673691 } ,
674692 } ,
675693 {
676- title : showTimestamps ( ) ? "Hide timestamps" : "Show timestamps" ,
694+ title : ( ( ) => {
695+ const next = nextTimestampsMode ( timestampsMode ( ) )
696+ if ( next === "hide" ) return "Hide timestamps"
697+ if ( next === "footer" ) return "Show timestamps under message"
698+ return "Show timestamps in gutter"
699+ } ) ( ) ,
677700 value : "session.toggle.timestamps" ,
678701 category : "Session" ,
679702 slash : {
680703 name : "timestamps" ,
681704 aliases : [ "toggle-timestamps" ] ,
682705 } ,
683706 run : ( ) => {
684- setTimestamps ( ( prev ) => ( prev === "show" ? "hide" : "show" ) )
707+ const next = nextTimestampsMode ( timestampsMode ( ) )
708+ setTimestamps ( ( ) => next )
685709 dialog . clear ( )
686710 } ,
687711 } ,
@@ -1096,6 +1120,7 @@ export function Session() {
10961120 thinkingMode,
10971121 showThinking,
10981122 showTimestamps,
1123+ timestampsMode,
10991124 showDetails,
11001125 showGenericToolOutput,
11011126 diffWrapMode,
@@ -1293,6 +1318,10 @@ const MIME_BADGE: Record<string, string> = {
12931318 "application/x-directory" : "dir" ,
12941319}
12951320
1321+ // Fixed gutter width: 5 cells for "HH:MM" + 1 trailing space. Hardcoded so the
1322+ // gutter column stays aligned across messages and never depends on locale.
1323+ const TIMESTAMP_GUTTER_WIDTH = 6
1324+
12961325function UserMessage ( props : {
12971326 message : UserMessage
12981327 parts : Part [ ]
@@ -1302,6 +1331,7 @@ function UserMessage(props: {
13021331} ) {
13031332 const ctx = use ( )
13041333 const local = useLocal ( )
1334+ const dialog = useDialog ( )
13051335 const text = createMemo ( ( ) => {
13061336 const texts = props . parts
13071337 . map ( ( x ) => {
@@ -1320,69 +1350,88 @@ function UserMessage(props: {
13201350 const color = createMemo ( ( ) => local . agent . color ( props . message . agent ) )
13211351 const queuedFg = createMemo ( ( ) => selectedForeground ( theme , color ( ) ) )
13221352 const metadataVisible = createMemo ( ( ) => queued ( ) || ctx . showTimestamps ( ) )
1353+ const isGutter = createMemo ( ( ) => ctx . timestampsMode ( ) === "gutter" )
13231354
13241355 const compaction = createMemo ( ( ) => props . parts . find ( ( x ) => x . type === "compaction" ) )
13251356
13261357 return (
13271358 < >
13281359 < Show when = { text ( ) } >
1329- < box
1330- id = { props . message . id }
1331- border = { [ "left" ] }
1332- borderColor = { color ( ) }
1333- customBorderChars = { SplitBorder . customBorderChars }
1334- marginTop = { props . index === 0 ? 0 : 1 }
1335- >
1360+ < box flexDirection = "row" marginTop = { props . index === 0 ? 0 : 1 } flexShrink = { 0 } >
1361+ < Show when = { isGutter ( ) } >
1362+ < box
1363+ width = { TIMESTAMP_GUTTER_WIDTH }
1364+ paddingTop = { 1 }
1365+ flexShrink = { 0 }
1366+ onMouseUp = { ( ) => DialogTimestamp . show ( dialog , props . message . time . created ) }
1367+ >
1368+ < text fg = { theme . textMuted } > { hourMinute ( props . message . time . created ) } </ text >
1369+ </ box >
1370+ </ Show >
13361371 < box
1337- onMouseOver = { ( ) => {
1338- setHover ( true )
1339- } }
1340- onMouseOut = { ( ) => {
1341- setHover ( false )
1342- } }
1343- onMouseUp = { props . onMouseUp }
1344- paddingTop = { 1 }
1345- paddingBottom = { 1 }
1346- paddingLeft = { 2 }
1347- backgroundColor = { hover ( ) ? theme . backgroundElement : theme . backgroundPanel }
1348- flexShrink = { 0 }
1372+ id = { props . message . id }
1373+ border = { [ "left" ] }
1374+ borderColor = { color ( ) }
1375+ customBorderChars = { SplitBorder . customBorderChars }
1376+ flexGrow = { 1 }
13491377 >
1350- < text fg = { theme . text } > { text ( ) } </ text >
1351- < Show when = { files ( ) . length } >
1352- < box flexDirection = "row" paddingBottom = { metadataVisible ( ) ? 1 : 0 } paddingTop = { 1 } gap = { 1 } flexWrap = "wrap" >
1353- < For each = { files ( ) } >
1354- { ( file ) => {
1355- const bg = createMemo ( ( ) => {
1356- if ( file . mime . startsWith ( "image/" ) ) return theme . accent
1357- if ( file . mime === "application/pdf" ) return theme . primary
1358- return theme . secondary
1359- } )
1360- return (
1361- < text fg = { theme . text } >
1362- < span style = { { bg : bg ( ) , fg : theme . background } } > { MIME_BADGE [ file . mime ] ?? file . mime } </ span >
1363- < span style = { { bg : theme . backgroundElement , fg : theme . textMuted } } > { file . filename } </ span >
1364- </ text >
1365- )
1366- } }
1367- </ For >
1368- </ box >
1369- </ Show >
1370- < Show
1371- when = { queued ( ) }
1372- fallback = {
1373- < Show when = { ctx . showTimestamps ( ) } >
1374- < text fg = { theme . textMuted } >
1375- < span style = { { fg : theme . textMuted } } >
1376- { Locale . todayTimeOrDateTime ( props . message . time . created ) }
1377- </ span >
1378- </ text >
1379- </ Show >
1380- }
1378+ < box
1379+ onMouseOver = { ( ) => {
1380+ setHover ( true )
1381+ } }
1382+ onMouseOut = { ( ) => {
1383+ setHover ( false )
1384+ } }
1385+ onMouseUp = { props . onMouseUp }
1386+ paddingTop = { 1 }
1387+ paddingBottom = { 1 }
1388+ paddingLeft = { 2 }
1389+ backgroundColor = { hover ( ) ? theme . backgroundElement : theme . backgroundPanel }
1390+ flexShrink = { 0 }
13811391 >
1382- < text fg = { theme . textMuted } >
1383- < span style = { { bg : color ( ) , fg : queuedFg ( ) , bold : true } } > QUEUED </ span >
1384- </ text >
1385- </ Show >
1392+ < text fg = { theme . text } > { text ( ) } </ text >
1393+ < Show when = { files ( ) . length } >
1394+ < box
1395+ flexDirection = "row"
1396+ paddingBottom = { metadataVisible ( ) ? 1 : 0 }
1397+ paddingTop = { 1 }
1398+ gap = { 1 }
1399+ flexWrap = "wrap"
1400+ >
1401+ < For each = { files ( ) } >
1402+ { ( file ) => {
1403+ const bg = createMemo ( ( ) => {
1404+ if ( file . mime . startsWith ( "image/" ) ) return theme . accent
1405+ if ( file . mime === "application/pdf" ) return theme . primary
1406+ return theme . secondary
1407+ } )
1408+ return (
1409+ < text fg = { theme . text } >
1410+ < span style = { { bg : bg ( ) , fg : theme . background } } > { MIME_BADGE [ file . mime ] ?? file . mime } </ span >
1411+ < span style = { { bg : theme . backgroundElement , fg : theme . textMuted } } > { file . filename } </ span >
1412+ </ text >
1413+ )
1414+ } }
1415+ </ For >
1416+ </ box >
1417+ </ Show >
1418+ < Show
1419+ when = { queued ( ) }
1420+ fallback = {
1421+ < Show when = { ctx . showTimestamps ( ) } >
1422+ < text fg = { theme . textMuted } >
1423+ < span style = { { fg : theme . textMuted } } >
1424+ { Locale . todayTimeOrDateTime ( props . message . time . created ) }
1425+ </ span >
1426+ </ text >
1427+ </ Show >
1428+ }
1429+ >
1430+ < text fg = { theme . textMuted } >
1431+ < span style = { { bg : color ( ) , fg : queuedFg ( ) , bold : true } } > QUEUED </ span >
1432+ </ text >
1433+ </ Show >
1434+ </ box >
13861435 </ box >
13871436 </ box >
13881437 </ Show >
0 commit comments