diff -r f8e1ea02978c -r 3081679abd00 htdocs/css/ticket.css
--- a/htdocs/css/ticket.css	Sat May 20 17:43:35 2006 +0200
+++ b/htdocs/css/ticket.css	Mon May 22 10:07:50 2006 +0200
@@ -46,6 +46,14 @@
  font-size: 100%;
  font-weight: normal;
 }
+.inlinebuttons input { 
+ float: right;
+ font-size: 70%;
+ border-width: 1px;
+ margin: 0 .5em .1em 1.5em;
+ padding: 0.1em;
+ background-color: #f6f6f6;
+}
 #changelog .changes { list-style: square; margin-left: 2em; padding: 0 }
 #changelog .comment { margin-left: 2em }
 
diff -r f8e1ea02978c -r 3081679abd00 htdocs/css/trac.css
--- a/htdocs/css/trac.css	Sat May 20 17:43:35 2006 +0200
+++ b/htdocs/css/trac.css	Mon May 22 10:07:50 2006 +0200
@@ -40,6 +40,19 @@ h1 :link, h1 :visited ,h2 :link, h2 :vis
 h1 :link, h1 :visited ,h2 :link, h2 :visited, h3 :link, h3 :visited,
 h4 :link, h4 :visited, h5 :link, h5 :visited, h6 :link, h6 :visited {
  color: inherit;
+}
+
+/* Heading anchors */
+.anchor:link, .anchor:visited {
+ border: none;
+ color: #d7d7d7;
+ font-size: .8em;
+ vertical-align: text-top;
+ visibility: hidden;
+}
+h1:hover .anchor, h2:hover .anchor, h3:hover .anchor,
+h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {
+ visibility: visible;
 }
 
 @media screen {
diff -r f8e1ea02978c -r 3081679abd00 htdocs/css/wiki.css
--- a/htdocs/css/wiki.css	Sat May 20 17:43:35 2006 +0200
+++ b/htdocs/css/wiki.css	Mon May 22 10:07:50 2006 +0200
@@ -21,19 +21,6 @@
 #overview .multi { color: #999 }
 #overview .ipnr { color: #999; font-size: 80% }
 #overview .comment { padding: 1em 0 0 }
-
-/* Heading anchors */
-.anchor:link, .anchor:visited {
- border: none;
- color: #d7d7d7;
- font-size: .8em;
- vertical-align: text-top;
- visibility: hidden;
-}
-h1:hover .anchor, h2:hover .anchor, h3:hover .anchor,
-h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {
- visibility: visible;
-}
 
 /* Styles for the page history table
    (extends the styles for "table.listing") */
diff -r f8e1ea02978c -r 3081679abd00 templates/macros.cs
--- a/templates/macros.cs	Sat May 20 17:43:35 2006 +0200
+++ b/templates/macros.cs	Mon May 22 10:07:50 2006 +0200
@@ -190,4 +190,8 @@ def:list_of_attachments(attachments, att
    <input type="submit" value="Attach File" />
   </div></form><?cs
  /if ?><?cs if:len(attachments) ?></div><?cs /if ?><?cs
+/def ?><?cs
+
+def:plural(base, count) ?><?cs
+ var:base ?><?cs if:count > 1 ?>s<?cs /if ?><?cs
 /def ?>
diff -r f8e1ea02978c -r 3081679abd00 templates/ticket.cs
--- a/templates/ticket.cs	Sat May 20 17:43:35 2006 +0200
+++ b/templates/ticket.cs	Mon May 22 10:07:50 2006 +0200
@@ -80,11 +80,40 @@
 <?cs call:list_of_attachments(ticket.attachments, ticket.attach_href) ?>
 <?cs /if ?>
 
+<?cs def:commentref(prefix, cnum) ?>
+<a href="#comment:<?cs var:cnum ?>"><small><?cs var:prefix ?><?cs var:cnum ?></small></a>
+<?cs /def ?>
+
 <?cs if:len(ticket.changes) ?><h2>Change History</h2>
 <div id="changelog"><?cs
  each:change = ticket.changes ?>
-  <h3 id="change_<?cs var:name(change) ?>" class="change"><?cs
-   var:change.date ?>: Modified by <?cs var:change.author ?></h3><?cs
+  <h3 <?cs if:change.cnum ?>id="comment:<?cs var:change.cnum ?>"<?cs /if ?> 
+      class="change"><?cs
+   var:change.date ?>: 
+    Modification <a href="#comment:<?cs var:change.cnum ?>"
+                    title="Permalink to comment:<?cs var:change.cnum ?>">
+     <?cs var:change.cnum ?></a>
+    by <?cs var:change.author ?> <?cs
+   if:change.cnum ?><?cs
+    set:nreplies = len(ticket.replies[change.cnum]) ?><?cs
+    if:nreplies || change.replyto ?><span class="threading"> &mdash; <?cs
+     if:change.replyto ?>in reply to: <?cs 
+      call:commentref('&uArr;', change.replyto) ?><?cs if nreplies ?> &ndash; <?cs /if ?><?cs
+     /if ?><?cs
+     if nreplies ?><?cs
+      call:plural('follow-up', nreplies) ?>: <?cs 
+      each:reply = ticket.replies[change.cnum] ?><?cs 
+       call:commentref('&dArr;', reply) ?><?cs 
+      /each ?><?cs 
+     /if ?></span><?cs
+    /if ?><?cs
+   /if ?></h3><?cs
+  if:change.cnum ?>
+   <form method="get" action="<?cs var:ticket.href ?>#comment"><div class="inlinebuttons">
+    <input type="hidden" name="replyto" value="<?cs var:change.cnum ?>" />
+    <input type="submit" value="Reply" title="Reply to comment <?cs var:change.cnum ?>" /></div>
+   </form><?cs 
+   /if ?><?cs
   if:len(change.fields) ?>
    <ul class="changes"><?cs
    each:field = change.fields ?>
@@ -277,6 +306,8 @@
 
  <div class="buttons">
   <input type="hidden" name="ts" value="<?cs var:ticket.ts ?>" />
+  <input type="hidden" name="replyto" value="<?cs var:ticket.replyto ?>" />
+  <input type="hidden" name="cnum" value="<?cs var:ticket.cnum ?>" />
   <input type="submit" name="preview" value="Preview" accesskey="r" />&nbsp;
   <input type="submit" value="Submit changes" />
  </div>
diff -r f8e1ea02978c -r 3081679abd00 trac/ticket/api.py
--- a/trac/ticket/api.py	Sat May 20 17:43:35 2006 +0200
+++ b/trac/ticket/api.py	Mon May 22 10:07:50 2006 +0200
@@ -186,7 +186,8 @@ class TicketSystem(Component):
 
     def get_link_resolvers(self):
         return [('bug', self._format_link),
-                ('ticket', self._format_link)]
+                ('ticket', self._format_link),
+                ('comment', self._format_comment_link)]
 
     def get_wiki_syntax(self):
         yield (
@@ -215,6 +216,29 @@ class TicketSystem(Component):
         return html.A(class_='missing ticket', rel='nofollow',
                       href=formatter.href.ticket(target))[label]
 
+    def _format_comment_link(self, formatter, ns, target, label):
+        type, id, cnum = 'ticket', '1', 0
+        href = None
+        if ':' in target:
+            elts = target.split(':')
+            if len(elts) == 3:
+                type, id, cnum = elts
+                href = formatter.href(type, id)
+        else:
+            # FIXME: the formatter should know which object the text being
+            #        formatted belongs to
+            if formatter.req:
+                path_info = formatter.req.path_info.strip('/').split('/', 2)
+                if len(path_info) == 2:
+                    type, id = path_info[:2]
+                    href = formatter.href(type, id)
+                    cnum = target
+        if href:
+            return html.A(label, href="%s#comment:%s" % (href, cnum),
+                          title="Comment %s for %s:%s" % (cnum, type, id))
+        else:
+            return label
+ 
     # ISearchSource methods
 
     def get_search_filters(self, req):
diff -r f8e1ea02978c -r 3081679abd00 trac/ticket/model.py
--- a/trac/ticket/model.py	Sat May 20 17:43:35 2006 +0200
+++ b/trac/ticket/model.py	Mon May 22 10:07:50 2006 +0200
@@ -180,7 +180,7 @@ class Ticket(object):
         self._old = {}
         return self.id
 
-    def save_changes(self, author, comment, when=0, db=None):
+    def save_changes(self, author, comment, when=0, db=None, cnum=''):
         """
         Store ticket changes in the database. The ticket must already exist in
         the database.
@@ -244,8 +244,8 @@ class Ticket(object):
         if comment:
             cursor.execute("INSERT INTO ticket_change "
                            "(ticket,time,author,field,oldvalue,newvalue) "
-                           "VALUES (%s,%s,%s,'comment','',%s)",
-                           (self.id, when, author, comment))
+                           "VALUES (%s,%s,%s,'comment',%s,%s)",
+                           (self.id, when, author, cnum, comment))
 
         cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
                        (when, self.id))
@@ -265,29 +265,30 @@ class Ticket(object):
         db = self._get_db(db)
         cursor = db.cursor()
         if when:
-            cursor.execute("SELECT time,author,field,oldvalue,newvalue "
+            cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
                            "FROM ticket_change WHERE ticket=%s AND time=%s "
                            "UNION "
-                           "SELECT time,author,'attachment',null,filename "
+                           "SELECT time,author,'attachment',null,filename,0 "
                            "FROM attachment WHERE id=%s AND time=%s "
                            "UNION "
-                           "SELECT time,author,'comment',null,description "
+                           "SELECT time,author,'comment',null,description,0 "
                            "FROM attachment WHERE id=%s AND time=%s "
                            "ORDER BY time",
                            (self.id, when, str(self.id), when, self.id, when))
         else:
-            cursor.execute("SELECT time,author,field,oldvalue,newvalue "
+            cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
                            "FROM ticket_change WHERE ticket=%s "
                            "UNION "
-                           "SELECT time,author,'attachment',null,filename "
+                           "SELECT time,author,'attachment',null,filename,0 "
                            "FROM attachment WHERE id=%s "
                            "UNION "
-                           "SELECT time,author,'comment',null,description "
+                           "SELECT time,author,'comment',null,description,0 "
                            "FROM attachment WHERE id=%s "
                            "ORDER BY time", (self.id,  str(self.id), self.id))
         log = []
-        for t, author, field, oldvalue, newvalue in cursor:
-            log.append((int(t), author, field, oldvalue or '', newvalue or ''))
+        for t, author, field, oldvalue, newvalue, permanent in cursor:
+            log.append((int(t), author, field, oldvalue or '', newvalue or '',
+                        permanent))
         return log
 
     def delete(self, db=None):
diff -r f8e1ea02978c -r 3081679abd00 trac/ticket/notification.py
--- a/trac/ticket/notification.py	Sat May 20 17:43:35 2006 +0200
+++ b/trac/ticket/notification.py	Mon May 22 10:07:50 2006 +0200
@@ -75,7 +75,7 @@ class TicketNotifyEmail(NotifyEmail):
         changes = ''
         if not self.newticket and modtime:  # Ticket change
             changelog = ticket.get_changelog(modtime)
-            for date, author, field, old, new in changelog:
+            for date, author, field, old, new, permanent in changelog:
                 self.hdf.set_unescaped('ticket.change.author', author)
                 pfx = 'ticket.change.%s' % field
                 newv = ''
diff -r f8e1ea02978c -r 3081679abd00 trac/ticket/web_ui.py
--- a/trac/ticket/web_ui.py	Sat May 20 17:43:35 2006 +0200
+++ b/trac/ticket/web_ui.py	Mon May 22 10:07:50 2006 +0200
@@ -268,9 +268,8 @@ class TicketModule(TicketModuleBase):
                 if comment:
                     req.hdf['ticket.comment'] = comment
                     # Wiki format a preview of comment
-                    req.hdf['ticket.comment_preview'] = wiki_to_html(comment,
-                                                                     self.env,
-                                                                     req, db)
+                    req.hdf['ticket.comment_preview'] = wiki_to_html(
+                        comment, self.env, req, db)
         else:
             req.hdf['ticket.reassign_owner'] = req.authname
             # Store a timestamp in order to detect "mid air collisions"
@@ -511,8 +510,10 @@ class TicketModule(TicketModuleBase):
             ticket['resolution'] = ''
 
         now = int(time.time())
+        cnum = req.args.get('cnum')        
         ticket.save_changes(req.args.get('author', req.authname),
-                            req.args.get('comment'), when=now, db=db)
+                            req.args.get('comment'), when=now, db=db,
+                            cnum=cnum)
         db.commit()
 
         try:
@@ -522,14 +523,24 @@ class TicketModule(TicketModuleBase):
             self.log.exception("Failure sending notification on change to "
                                "ticket #%s: %s" % (ticket.id, e))
 
-        req.redirect(req.href.ticket(ticket.id))
+        fragment = ''
+        if cnum:
+            fragment = '#comment:'+cnum
+        req.redirect(req.href.ticket(ticket.id) + fragment)
 
     def _insert_ticket_data(self, req, db, ticket, reporter_id):
         """Insert ticket data into the hdf"""
+        replyto = req.args.get('replyto')
+        req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary'])
         req.hdf['ticket'] = ticket.values
-        req.hdf['ticket.id'] = ticket.id
-        req.hdf['ticket.href'] = req.href.ticket(ticket.id)
-
+        req.hdf['ticket'] = {
+            'id': ticket.id,
+            'href': req.href.ticket(ticket.id),
+            'replyto': replyto
+            }
+
+        # -- Ticket fields
+        
         for field in TicketSystem(self.env).get_ticket_fields():
             if field['type'] in ('radio', 'select'):
                 value = ticket.values.get(field['name'])
@@ -547,39 +558,77 @@ class TicketModule(TicketModuleBase):
             req.hdf['ticket.fields.' + name] = field
 
         req.hdf['ticket.reporter_id'] = reporter_id
-        req.hdf['title'] = '#%d (%s)' % (ticket.id, ticket['summary'])
-        req.hdf['ticket.description.formatted'] = wiki_to_html(ticket['description'],
-                                                               self.env, req, db)
+        req.hdf['ticket.description.formatted'] = wiki_to_html(
+            ticket['description'], self.env, req, db)
 
         req.hdf['ticket.opened'] = format_datetime(ticket.time_created)
         req.hdf['ticket.opened_delta'] = pretty_timedelta(ticket.time_created)
         if ticket.time_changed != ticket.time_created:
-            req.hdf['ticket.lastmod'] = format_datetime(ticket.time_changed)
-            req.hdf['ticket.lastmod_delta'] = pretty_timedelta(ticket.time_changed)
+            req.hdf['ticket'] = {
+                'lastmod': format_datetime(ticket.time_changed),
+                'lastmod_delta': pretty_timedelta(ticket.time_changed)
+                }
+
+        # -- Ticket Change History
 
         changelog = ticket.get_changelog(db=db)
         curr_author = None
-        curr_date   = 0
+        curr_date = 0
+        autonum = 0
+        replies = {}
         changes = []
-        for date, author, field, old, new in changelog:
+        current = None
+        for date, author, field, old, new, permanent in changelog:
             if date != curr_date or author != curr_author:
-                changes.append({
+                current = {
                     'date': format_datetime(date),
                     'author': author,
                     'fields': {}
-                })
+                }
+                changes.append(current)
                 curr_date = date
                 curr_author = author
+                if permanent:
+                    autonum += 1
+                    current['cnum'] = autonum
             if field == 'comment':
-                changes[-1]['comment'] = wiki_to_html(new, self.env, req, db)
+                # numbered comments threading
+                if permanent:
+                    if old:
+                        current['cnum'] = old
+                        this_num = old
+                        parentsep = this_num.rfind('.') 
+                        if parentsep > -1: # reply 1.2.3 -> parent 1.2
+                            parent = this_num[:parentsep]
+                            current['replyto'] = parent
+                            replies.setdefault(parent, []).append(this_num)
+                            autonum -= 1 # autonum skip replies
+                    else:
+                        this_num = str(autonum)
+                # prefill comment textarea with the cited parent, when replying
+                if permanent and replyto == this_num and \
+                       not 'comment' in req.args:
+                    comment_num = this_num
+                    req.hdf['ticket.comment'] = '\n'.join(
+                        ['Replying to [comment:%s %s:]' % (replyto, author)] +
+                        ['> %s' % line for line in new.splitlines()] + [''])
+                current['comment'] = wiki_to_html(new, self.env, req, db)
             elif field == 'description':
-                changes[-1]['fields'][field] = ''
+                current['fields'][field] = ''
             else:
-                changes[-1]['fields'][field] = {'old': old,
-                                                'new': new}
-        req.hdf['ticket.changes'] = changes
-
-        # List attached files
+                current['fields'][field] = {'old': old, 'new': new}
+        if replyto:
+            cnum = '%s.%d' % (replyto, len(replies.get(replyto, [])) + 1)
+        else:
+            cnum = autonum + 1
+        req.hdf['ticket'] = {
+            'changes': changes,
+            'replies': replies,
+            'cnum': cnum
+           }
+
+        # -- Ticket Attachments
+
         req.hdf['ticket.attachments'] = attachments_to_hdf(self.env, req, db,
                                                            'ticket', ticket.id)
         if req.perm.has_permission('TICKET_APPEND'):

